Skip to content

feat(db): auto-apply tracked script data migrations in db:migrate#5497

Merged
TheodoreSpeaks merged 3 commits into
stagingfrom
feat/self-host-migration
Jul 8, 2026
Merged

feat(db): auto-apply tracked script data migrations in db:migrate#5497
TheodoreSpeaks merged 3 commits into
stagingfrom
feat/self-host-migration

Conversation

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator

Summary

  • Self-hosted deployments only ran drizzle SQL migrations — one-off TS backfill scripts never reached them. This adds a minimal run-once script-migration mechanism to the existing db:migrate entrypoint, so compose/helm pick it up with zero config changes
  • New packages/db/script-migrations/: ScriptMigration interface, explicit registry, and a runner invoked from migrate.ts after SQL migrations succeed, inside the same advisory-lock session. Applied scripts are recorded by name in a runner-managed script_migrations table (mirrors how drizzle manages __drizzle_migrations — deliberately not in schema.ts)
  • Ports the order_key fractional-indexing backfill as entry 0001 (old apps/sim/scripts/backfill-table-order-keys.ts deleted): same per-table advisory lock + deterministic re-key from position, chunked writes now via 2-param unnest instead of VALUES lists. Per-table failures are isolated, then the migration throws so it stays pending and retries only still-unkeyed tables
  • Moves the dependency-free fractional-indexing.ts into @sim/utils/fractional-indexing so it's available inside the slim migrations image; lib/table/order-key.ts stays as the app-side wrapper
  • Hosted runs the same path once as a near-no-op (insert path dual-writes order_key unconditionally, so the pending set is empty), records the journal row, and skips thereafter
  • Authoring rules are documented on the interface: idempotent/resumable, db-level imports only, scripts own their transactions, and a SQL migration must never drop a column a registered script still reads (delete the script from the registry in the same PR instead)

Type of Change

  • New feature

Testing

Verified end-to-end against a scratch pgvector container: all 255 SQL migrations + script migration apply on a fresh DB; seeded 12k rows (reverse-inserted, crossing the chunk boundary) with NULL order_key, re-ran db:migrate — 0 NULLs left, key order matches position order exactly, already-keyed tables untouched, third run no-ops. lint:check, check:api-validation:strict, type-check (utils/db/sim), and both package test suites pass

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@TheodoreSpeaks TheodoreSpeaks requested a review from a team as a code owner July 7, 2026 23:57
@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Jul 8, 2026 12:13am

Request Review

@cursor

cursor Bot commented Jul 7, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes the deploy-critical migration runner and performs a user_table_rows data backfill during upgrade; design is idempotent with per-table locking and retry-on-failure, but a failed deploy blocks until the backfill succeeds.

Overview
Adds run-once TypeScript data migrations to the existing db:migrate flow so self-hosted deploys get the same backfills as hosted, with no compose/helm changes.

After Drizzle SQL migrations succeed (still under the migration advisory lock), runScriptMigrations runs pending entries from an append-only registry, records each name in a runner-managed script_migrations table, and clears statement_timeout / lock_timeout on that session so long backfills can wait on app locks instead of failing with 55P03. assertLockSessionHeld is extracted and invoked before the script phase so a recycled connection cannot run scripts without the lock.

The order_key backfill moves from deleted apps/sim/scripts/backfill-table-order-keys.ts into registry entry 0001_backfill_table_order_keys: same per-table advisory lock and position-ordered keys, with chunked updates via unnest (two bind params per chunk) instead of large VALUES lists. Per-table errors are logged and the migration throws so the journal row is not written and only still-unkeyed tables retry.

fractional-indexing is published as @sim/utils/fractional-indexing (tests included) so the slim migrations image can use it; order-key.ts keeps a thin wrapper over that package. The standalone repair script’s docs now point at the script migration for NULL backfills vs collation repair.

Reviewed by Cursor Bugbot for commit 4983f86. Bugbot is set up for automated code reviews on this repo. Configure here.

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3a828bd. Configure here.

Comment thread packages/db/scripts/migrate.ts
@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a run-once TypeScript script migration framework to the existing db:migrate entrypoint, ensuring self-hosted deployments automatically run TS data migrations (previously manual) with zero config changes. It ports the order_key fractional-indexing backfill as 0001, moves fractional-indexing.ts to @sim/utils for availability in the slim migrations Docker image, and deletes the now-redundant standalone backfill script.

  • New packages/db/script-migrations/: ScriptMigration interface with authoring rules, an explicit registry, and a runner (runScriptMigrations) invoked from migrate.ts after SQL migrations succeed within the same advisory-lock session; applied scripts are journaled in a runner-managed script_migrations table.
  • 0001_backfill_table_order_keys: Ports the order_key backfill with per-table advisory locks, chunked unnest-based UPDATEs instead of VALUES lists, per-table error isolation, and idempotent retry semantics.
  • assertLockSessionHeld(): Extracted from runMigrationsWithRetry into a shared helper called both at each DDL retry and once before script migrations run, closing a session-recycle window.

Confidence Score: 5/5

Safe to merge — the migration framework is additive and well-guarded; the backfill is idempotent and isolated per table; the only finding is a detached JSDoc block from the assertLockSessionHeld refactor.

The core machinery (advisory-lock guard, timeout resets, ON CONFLICT DO NOTHING journal insert, per-table error isolation) is all correct and addresses issues raised in prior review rounds. The backfill logic is idempotent: re-runs skip fully keyed tables, the unnest-based UPDATE avoids parameter limits, and per-table failures let the migration stay pending and retry only unfinished tables. The only finding is a cosmetic orphaned comment left behind when assertLockSessionHeld was extracted from runMigrationsWithRetry.

packages/db/scripts/migrate.ts has a minor doc inconsistency from the refactor; all other files are clean.

Important Files Changed

Filename Overview
packages/db/scripts/migrate.ts Adds assertLockSessionHeld() call before script migrations and extracts pid-guard into a reusable helper; has an orphaned JSDoc comment block left from the refactor.
packages/db/script-migrations/index.ts Implements runScriptMigrations: resets timeouts, creates journal table, filters pending migrations, and records each applied migration with ON CONFLICT DO NOTHING guard. Clean implementation.
packages/db/script-migrations/0001_backfill_table_order_keys.ts Ports order_key backfill to script migration; uses unnest() instead of VALUES, per-table transactions with advisory locks, chunked writes, per-table error isolation, and throws on any failure to keep migration retryable.
packages/db/script-migrations/types.ts ScriptMigration interface with well-documented authoring rules (idempotent, db-level imports, owns transactions). Solid contract definition.
packages/utils/src/fractional-indexing.ts Moved from apps/sim/lib/fractional-indexing/ to packages/utils/src/ — content unchanged, making it available in the slim migrations image.
packages/utils/package.json Adds ./fractional-indexing export condition following existing project patterns for direct .ts file exports.
apps/sim/lib/table/order-key.ts Import path updated from @/lib/fractional-indexing/fractional-indexing to @sim/utils/fractional-indexing — trivial, correct change.

Reviews (4): Last reviewed commit: "improvement(db): re-verify advisory-lock..." | Re-trigger Greptile

Comment thread packages/db/scripts/migrate.ts
Comment thread packages/db/script-migrations/index.ts Outdated
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a run-once TypeScript script-migration mechanism layered on top of the existing db:migrate entrypoint, so self-hosted deployments automatically apply one-off data backfills without any additional orchestration. It also relocates the dependency-free fractional-indexing implementation into @sim/utils so the slim migrations image can import it.

  • packages/db/script-migrations/ — new ScriptMigration interface, explicit append-only registry, and a runner that creates the script_migrations tracking table, deduplicates names, checks required env vars, runs pending migrations in order, and records each by name only after up() resolves (mirroring drizzle's __drizzle_migrations journal).
  • 0001_backfill_table_order_keys — ports the old standalone backfill script as the first registry entry; uses per-table advisory locks and unnest-based chunked UPDATEs; per-table failures are isolated so remaining tables still run, then the migration throws to stay pending for retry.
  • packages/utils/src/fractional-indexing.ts — 100%-identical rename from apps/sim/lib/fractional-indexing/; exported via the new ./fractional-indexing entry in packages/utils/package.json.

Confidence Score: 4/5

The migration framework and backfill implementation are well-structured — advisory locking, per-table isolation, idempotency, and retry semantics are all correct. Two small gaps in migrate.ts are safe in every primary scenario but could surface as operational friction on live self-hosted databases.

The core design is sound and end-to-end tested. The lock_timeout = '5s' left over from the DDL phase is inherited by backfill DML statements; for the primary target (self-hosted, app down during migration) there is no contention and no impact, but a live upgrade with concurrent row writes could see sporadic per-table failures that require an extra upgrade cycle to clear. The missing pid/session check before runScriptMigrations is very unlikely to matter given max_lifetime: null, but it breaks the symmetry of the existing guard in runMigrationsWithRetry. Neither issue introduces data loss or corruption.

packages/db/scripts/migrate.ts — the two gaps described above both live here. All other files look correct.

Important Files Changed

Filename Overview
packages/db/scripts/migrate.ts Adds runScriptMigrations(client) call after SQL migrations succeed, inside the advisory-lock session; inherits lock_timeout = '5s' from the DDL phase and lacks a pid-check before the script migration phase.
packages/db/script-migrations/index.ts New runner: creates script_migrations tracking table, deduplicates names, checks requiredEnv, runs pending migrations in registry order, and records each by name after up() resolves — mirrors drizzle's journal pattern correctly.
packages/db/script-migrations/types.ts Clean ScriptMigration interface with well-documented authoring rules (idempotent, db-level imports only, owns transactions, schema dependency rules).
packages/db/script-migrations/0001_backfill_table_order_keys.ts Ports the order_key backfill as a registered script migration; per-table advisory lock, chunked unnest-based UPDATE, per-table error isolation with final throw to keep migration pending on partial failure — all correct.
packages/utils/src/fractional-indexing.ts 100% content-identical rename from apps/sim/lib/fractional-indexing/fractional-indexing.ts; no logic changes.
packages/utils/package.json Adds the ./fractional-indexing export entry consistent with the rest of the package's export map.
apps/sim/lib/table/order-key.ts Import updated from the old @/lib/fractional-indexing/fractional-indexing alias to @sim/utils/fractional-indexing; no logic changes.
apps/sim/scripts/backfill-table-order-keys.ts Deleted; functionality ported into packages/db/script-migrations/0001_backfill_table_order_keys.ts with improvements.
apps/sim/scripts/repair-table-order-key-collation.ts Minor comment update only — reference to the deleted backfill script replaced with the new script migration name; no logic changes.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Runner as migrate.ts
    participant PG as PostgreSQL
    participant SM as script_migrations table

    Runner->>PG: connectWithRetry()
    Runner->>PG: pg_try_advisory_lock(MIGRATION_LOCK_KEY) [session-level]
    Runner->>PG: "SET statement_timeout=0, lock_timeout='5s'"
    Runner->>PG: drizzle migrate() — SQL migrations
    PG-->>Runner: SQL migrations applied
    Note over Runner: runScriptMigrations(client)
    Runner->>PG: CREATE TABLE IF NOT EXISTS script_migrations
    Runner->>SM: SELECT name FROM script_migrations
    SM-->>Runner: applied set
    loop For each pending script migration
        Runner->>Runner: check requiredEnv
        Runner->>PG: BEGIN (sql.begin)
        Runner->>PG: pg_advisory_xact_lock(table-key) [tx-level]
        Runner->>PG: "SELECT id FROM user_table_rows WHERE table_id=X ORDER BY position"
        Runner->>Runner: generateNKeysBetween(null, null, n)
        loop chunks of 5000
            Runner->>PG: UPDATE user_table_rows SET order_key FROM unnest(ids, keys)
        end
        Runner->>PG: COMMIT
        Runner->>SM: INSERT INTO script_migrations (name)
    end
    Runner->>PG: pg_advisory_unlock(MIGRATION_LOCK_KEY)
    Runner->>PG: warnOnInvalidIndexes()
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Runner as migrate.ts
    participant PG as PostgreSQL
    participant SM as script_migrations table

    Runner->>PG: connectWithRetry()
    Runner->>PG: pg_try_advisory_lock(MIGRATION_LOCK_KEY) [session-level]
    Runner->>PG: "SET statement_timeout=0, lock_timeout='5s'"
    Runner->>PG: drizzle migrate() — SQL migrations
    PG-->>Runner: SQL migrations applied
    Note over Runner: runScriptMigrations(client)
    Runner->>PG: CREATE TABLE IF NOT EXISTS script_migrations
    Runner->>SM: SELECT name FROM script_migrations
    SM-->>Runner: applied set
    loop For each pending script migration
        Runner->>Runner: check requiredEnv
        Runner->>PG: BEGIN (sql.begin)
        Runner->>PG: pg_advisory_xact_lock(table-key) [tx-level]
        Runner->>PG: "SELECT id FROM user_table_rows WHERE table_id=X ORDER BY position"
        Runner->>Runner: generateNKeysBetween(null, null, n)
        loop chunks of 5000
            Runner->>PG: UPDATE user_table_rows SET order_key FROM unnest(ids, keys)
        end
        Runner->>PG: COMMIT
        Runner->>SM: INSERT INTO script_migrations (name)
    end
    Runner->>PG: pg_advisory_unlock(MIGRATION_LOCK_KEY)
    Runner->>PG: warnOnInvalidIndexes()
Loading

Reviews (2): Last reviewed commit: "fix(db): reset session lock_timeout befo..." | Re-trigger Greptile

Comment thread packages/db/scripts/migrate.ts
Comment thread packages/db/scripts/migrate.ts
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

@TheodoreSpeaks TheodoreSpeaks merged commit 12fb4a9 into staging Jul 8, 2026
18 checks passed
@TheodoreSpeaks TheodoreSpeaks deleted the feat/self-host-migration branch July 8, 2026 00:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant